<?php
/**
* @name Simple PHP File Cacher
* @author Firstborn (1stborm at gmail dot com)
* @license GNU GENERAL PUBLIC LICENSE V2
* @see http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt
* @created 16.06.2010;
*/
if(!__FILE_CACHER__)
{
    define('__FILE_CACHER__', true);

    class FileCacher
    {
        public static
    		    $cache_directory = '',
                $auto_realocate = false;

        protected
        /** Cache rules       **/
                $_cache_size  = 2097152/*bytes*/,
                $_cache_expire = 18000/*sec*/,
                $_cache_file_name = 'data.fc',
                $_cache_var_exprire = 300/*sec*/,
        
        /** Compression rules **/
                $_compression = true,
                $_compress_start_length = 300,
                $_compression_functions = 'bzcompress/bzdecompress'; 

        /** Core rules (Dont change if you dont know what you do) **/
        private
                $_file = null,          $_index_length = 0,		$_cache_file_size = 0,  $_data_offset = 0,	
                $_dataset_size = 0,     $_execution_time = 0,	$_shift_offset = 0,		$_header_size = 0,	
           	    $_key_index = null,     $_data_write_mask = '', $_data_read_mask = '',  $_memo = array(),
           	    $_index_size = 0,       $_data_chunks = 0,      $_file_size = 0,        $_is_valid = false,
                $_alloc_shift = 0,
                /**
                * Dataset :
                * 	`key` 	 int
                * 	`expire` unsigned int
                * 	`type`   unsigned char
                * 	`shift`  unsigned short		
                * 	`data`   char(128)
                */
                $_dataset = array ('key' => 4, 'expire' => 4, 'type' => 1, 'shift' => 2, 'data' => 128),
                
                /**
                * Index:
                * 	`shift`  unsigned short
                * 	`key`	 int
                * 	`expire` int
                */
                $_index  = array('shift' => 2, 'key' => 4, 'expire' => 4),
                
                /**
                * Header:
                * 	`auto_realocate` char
                * 	`compressor`	 int
                * 	`cache_size`	 unsigned int
                * 	`index_size`	 unsigned short
                * 	`expire`		 unsigned int
                *   `alloc_shift`    unsigned short     
                * 
                */
                $_header = array('auto_realocate' => 1, 'compressor' => 4, 'cache_size' => 4, 'index_size' => 2, 'expire' => 4, 'alloc_shift' => 2);
        
        private static 
    		    $self = null; 
        /**
        * Predefined values (constants used as the numbers of performance reasons )
        *   Type:
        *       _ARRAY          = 1
        *       _OBJECT         = 2
        *       _INTEGER        = 4
        *       _DOUBLE         = 8
        *       _STRING         = 16
        *   Data:
        *       _COMPRESSED     = 32
        */

        /**
        * Constructor
        *
        * @param string $dir
        * @return FileCacher
        */
        public function __construct($dir = __FILE__)
        {
            $file = dirname($dir).'/'.$this->_cache_file_name;

            $this->_compression = function_exists('bzcompress') && $this->_compression ? $this->_compression_functions : null;

    	    $this->_is_valid =  $this->_init(fopen($file, 'r+')) 
                                    ? $this->_checkHeader() && $this->_loadIndex() 
                                    : ( 
                                        $this->_init(fopen($file, 'w+')) 
                                        ? ftruncate($this->_file, $this->_cache_file_size) 
                                        : false
                                      );  
        }

        /**
        * Return singleton
        * @return FileCacher
        */
        public static function getInstance()
        {
		    if(!self::$self)
		    {
			    self::$self = new FileCacher(self::$cache_directory);
		    }

		    return self::$self;
        }

        /**
        * Unset singleton instance of class;
        */
        public static function disposeInstance()
        {
		    self::$self = null;
        }

        /**
        * Write mixed data into cache
        *
        * @param string $key
        * @param mixed $value
        * @param int $lifetime
        * @return bool
        */
        public function set($key, $value, $lifetime)
        {
            if(!$this->_is_valid)
            {
			    return false;
            }
            
            $type = is_array($value) ? 1 : (is_object($value) ? 2 : (is_int($value) ? 4 : (is_double($value) || is_float($value) ? 8 : 16)));

            $crc32_key = crc32($key);

            $data = $type & ~4 & ~8 ? serialize($value) : $value;

            $hash = md5($data, true);

            if(!isset($this->_memo[$crc32_key]) || $this->_memo[$crc32_key]['hash'] != $hash);
            {
			    $expire = $lifetime + $this->_execution_time;
			    
			    $this->_memo[$crc32_key] = array('value' => $value, 'hash' => $hash, 'expire' => $expire);

	            if($this->_compression && $type & ~4 && isset($data{$this->_compress_start_length}))
	            {
				    $callback = substr($this->_compression_functions, 0, strpos($this->_compression_functions,'/'));
				    $data = $callback($data);
				    $type |= 32;
	            }

	            $offset = 0;

	            return $this->_allocate($crc32_key, $type, $data, $expire);
            }
        }
        
        public function isValid()
        {
		    return $this->_is_valid;
        }

        /**
        * Permanently delete data from cache file
        *
        * @param string $key
        * @return void
        */
        public function kill($key)
        {
    	    if(!$this->_is_valid)
            {
			    return;
            }
            
            $crc32_key = crc32($key);
            unset($this->_key_index[$crc32_key]);
            $this->_dealocate($crc32_key);
        }

        /**
        * Read data from cache
        *
        * @param string $key
        * @return mixed
        */
        public function get($key)
        {
    	    if(!$this->_is_valid)
            {
			    return false;
            }
            
            $crc32_key = crc32($key);
            
            if(!isset($this->_memo[$crc32_key]['value']) && isset($this->_key_index[$crc32_key]) && $this->_key_index[$crc32_key]['expire'] > $this->_execution_time)
            {
        	    $this->_memo[$crc32_key] = array();

        	    if($this->_detect($crc32_key, $this->_memo[$crc32_key], $this->_key_index[$crc32_key]['shift']))
        	    {
        		    return $this->_memo[$crc32_key]['value'];
			    }

                $this->_memo[$crc32_key] = array('value' => $value, 'expire' => $block['expire']);
            }

            return null;
        }
        
        /**
        * Initialize class members
        *
        * @return bool
        */
        protected function _init($handler)
        {
		    if($handler)
		    {
			    $this->_file = $handler; 
			    $this->_execution_time = &$_SERVER['REQUEST_TIME'];
	            $this->_dataset_size = array_sum($this->_dataset);
	            $this->_header_size = array_sum($this->_header);
	            $this->_index_size  = array_sum($this->_index); 
	            $this->_data_offset = $this->_header_size;
	            $this->_cache_file_size = $this->_cache_size + $this->_header_size;
	            $this->_data_chunks = (int)($this->_cache_file_size / $this->_dataset_size);
	            $this->_data_read_mask  = 'i1key/I1expire/c1type/S1shift/a'.$this->_dataset['data'].'data';
	            $this->_data_write_mask = 'i1I1c1S1a128';
	            $this->_shift_offset = $this->_dataset_size - $this->_dataset['data'] - $this->_dataset['shift'];
	            $this->_file_size = $this->_cache_file_size + $this->_header_size;
			    
			    return true; 
		    }
		    
            return false; 
        }

        private function _loadIndex()
        {
            fseek($this->_file, $this->_file_size);
            
            while($index = fread($this->_file, $this->_index_length))
            {
                $index = unpack('S1shift/i1key/I1expire', $index);
                $this->_key_index[$index['key']] = array('shift' => $index['shift'], 'expire' => $index['expire']);
            }
            
            return true;
        }

        private function _saveIndex()
        {
            /*string*/ $index = '';

            fseek($this->_file, $this->_file_size);

            if($this->_key_index)
            {
                ksort($this->_key_index);

                foreach($this->_key_index as $key => $info)
                {
                    $index .= pack('S1i1I1', $info['shift'], $key, $info['expire']);
                }

                $this->_index_length = sizeof($this->_key_index) * $this->_index_size;

                ftruncate($this->_file, $this->_file_size + $this->_index_length);
                
                fwrite($this->_file, $index, $this->_index_length);
            }
        }

        /**
        * Put specific header with current state of class into cache file
        *
        * @param int $write_cursor
        * @return bool
        */
        private function _writeHeader()
        { 
            fseek($this->_file, 0);
            return fwrite($this->_file, pack('c1i1I1S1I1',self::$auto_realocate ? 1 : 0, $this->_compression ? crc32($this->_compression) : 0, $this->_cache_size, $this->_index_length, $this->_execution_time), $this->_header_size);
        }

        /**
        * Reads the file header and compares it with the current class settings
        *
        * @return bool
        */
        private function _checkHeader()
        {
            fseek($this->_file, 0);

            $header = unpack('c1realocate/i1compressor/I1cache_size/S1index_length/I1expire/S1alloc', fread($this->_file, $this->_header_size));

            if($header['realocate'] || ( $header['compressor'] == crc32($this->_compression) && $this->_cache_size == $header['cache_size'] && $header['expire'] < $this->_execution_time))
            {
                $header['realocate'] && $this->_cache_size = $cache_size;
                $this->_index_length = $header['index_length'];
                $this->_alloc_shift = $header['alloc'];
                return true;
            }

            return false;
        }

        private function _dealocate($key)
        {
    	    unset($this->_key_index[crc32($key)]);
        }

        private function _detect($key, &$value, $entry_point = 0)
        {
            fseek($this->_file, $this->_data_offset + ($entry_point * $this->_dataset_size));

   		    while(true)
   		    {
			    $data = unpack($this->_data_read_mask, fread($this->_file, $this->_dataset_size));

			    if($data['expire'] < $this->_execution_time)
			    {
				    return false;
			    }
			    
			    if($data['key'] == $key)
			    {
				    $value['value'] .= $data['data'];

				    if($data['shift'] != 0)
				    {
					    fseek($this->_file, ($this->_dataset_size * $data['shift']) + $this->_data_offset);
					    $shift = $data['shift'];
				    }

				    if($data['key'] && $data['shift'] == 0) break;
			    }
   		    }

   		    if($data['type'] & 32	&& $value['value'])
   		    {
			    $callback = substr($this->_compression, strpos($this->_compression,'/') + 1); 
			    $value['value'] = $callback($value['value']); 	
   		    } 
		    
		    $value['hash'] = md5($value['value'], true);

		    if($data['type'] & ~1 & ~2)
		    {
			    $value['value'] = unserialize($value['value']);
		    }

		    $value['expire'] = $_expire;

   		    return $value ? true : false;
        }

        /**
        * Allocate data into file
        *
        * @param string $key
        * @param int $size
        * @param int $type
        * @param int $expire
        */
        private function _allocate($key, $type, $value, $expire = null)
        {
            fseek($this->_file, $this->_data_offset);
            
       	    for($blocks = 0, $shift = $this->_key_index[$key]['shift'] ? $this->_key_index[$key]['shift'] : $this->_alloc_shift; $shift < $this->_data_chunks; $shift++)
       	    {
                $data = substr($value, $blocks++ * $this->_dataset['data'], $this->_dataset['data']);
                
                if(!$data)
                {
				    break;	
                }

                $dataset = unpack($this->_data_read_mask, fread($this->_file, $this->_dataset_size));

       		    if(!$dataset['key'] || $dataset['key'] == $key || $dataset['expire'] < $this->_execution_time)
                {
                    fseek($this->_file, ($this->_dataset_size * $shift) + $this->_data_offset);
            	    $shifts[] = $shift;
            	    fwrite($this->_file, pack($this->_data_write_mask, $key, $expire, $type, 0, $data));
                    
                    if ($dataset['shift'] != 0) $shift = $dataset['shift'];
                }
		    }

            $this->_alloc_shift = $shift;
            
            $this->_key_index[$key] = array('shift' => $shifts[0], 'expire' => $expire);

		    for($i = 0, $mi = sizeof($shifts) - 1, $offset = $this->_shift_offset + $this->_data_offset; $i < $mi; $i++)
		    {
			    fseek($this->_file, ($shifts[$i] * $this->_dataset_size) + $offset);
			    fwrite($this->_file, pack('S1', $shifts[$i+1]));
		    }

            return $mi ? true : false;
        }

        /**
        * Destructor
        */
        function __destruct()
        {
            if($this->_file)
            {
			    $this->_saveIndex(); 
			    $this->_writeHeader();
			    fclose($this->_file);
            }
        }
    }
}